原文地址 https://medium.com/airtable-eng/migrating-airtable-to-mysql-8-0-809f0398a493作者 Doug Forster, Bin Gao, Brian Larson, Martin Michelsen, Doci Mou, Alexander Sorokin, and Andrew Wang简介
去年,我们把 Airtable 的 MySQL 数据库从 5.6 升级到了 8.0。MySQL 大版本升级总是需要精心策划、测试和部署;由于我们一次性升级了两个大版本,所以这次升级更加困难。这对我们的基础架构团队来说是一项艰巨的任务,花了一年多时间才得以顺利完成。作为该项目的一部分,我们为我们的 MySQL 基础架构引入了强大的测试与运维新功能,并在这个过程中学到了许多关于 MySQL 内部实施细节的知识。我们将在本文中分享经验和心得。系统背景
MySQL 是 Airtable 的主要存储系统。我们选择 MySQL 是因为它既稳定又高效。我们在 AWS RDS 运行 MySQL,并对其基础架构进行了大幅度改进,使得数据库的配置、管理和监控变得简单许多。我们之所以决定升级到 MySQL 8.0,是因为与 5.6 相比,它有极具吸引力的新功能和性能提升:- 支持使用 ALGORITHM=INSTANT 的在线 schema 变更(https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html)。这意味着某些类型的 schema 变更(包括添加列,这是我们最常见的 schema 变更类型)可以作为仅元数据的快速操作来执行。
- JSON 数据类型(https://dev.mysql.com/doc/refman/8.0/en/json.html)。一直以来,我们使用包含 JSON blob 的文本类型列来解决 MySQL 缺乏在线 schema 变更的问题,尤其是对于大型表而言。
- 其他新功能包括常用表表达式(https://dev.mysql.com/doc/refman/8.0/en/with.html)、窗口函数(https://dev.mysql.com/doc/refman/8.0/en/window-functions.html)、SKIP LOCKED(https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html) 以及更多类型的索引,这些功能在不同情况下非常有用。
- 总体性能提升。据 Oracle 报告,MySQL 8.0 在某些情况下的处理速度是原来的两倍。
在开始这个项目时,我们就知道这将是一项艰巨的任务。任何重大的基础架构变更都伴随着巨大的风险,而升级主存储系统将这一风险提升到了新的高度。最重要的是,我们必须避免在正确性、性能或可用性方面出现任何问题,以免影响直面客户的工作。自动化和预生产测试
项目第一阶段的重点是针对 MySQL 8.0 运行现有的自动化测试套件。我们配置了 CI 系统,以便针对每个拉取请求同时运行 MySQL 5.6 和 8.0 的所有测试。MySQL 8.0 的失败会发出警告,但不会阻止合并。我们还升级了一些非生产测试环境。这次测试发现了一些语法或兼容性问题,这些问题相对来说比较容易解决:自 5.6 版以来,新增了许多关键字和保留字(https://dev.mysql.com/doc/refman/8.0/en/keywords.html#keywords-new-in-current-series)。我们的一个表中有一列名为 grouping
,但在 8.0.1 中 GROUPING
变成了保留字。我们对代码进行了调整,找出所有受影响的查询,并添加了反引号来转义列名。
8.0 中的用户管理与 5.6 不兼容。这是我们自己造成的问题,因为我们跳过了 5.7(作为桥接版本),而且我们试图直接从 5.6 主节点到 8.0 从节点。我们试图通过仅使用这两个版本共有的功能部分来解决这个问题,但由于添加新 MySQL 用户的操作比较少见,最终我们决定不深究这个兼容性问题。
在项目的这一阶段,我们还花了大量时间构建仪表盘、监控和运维工具,用于配置和升级 MySQL 8 实例。使用生产流量进行测试
经过几个月成功的自动化测试和预生产测试,我们确信我们的应用程序和 MySQL 8 不存在基础问题。然而,我们仍然需要了解 MySQL 8 在规模更大、查询种类更多的生产工作负载下的性能如何。我们最重要的两个 MySQL 集群是:(1)基础分片 (base shards),用于存储表和单元格等 Airtable 基础相关的基础范围数据 (base-scoped data);(2)主分片 (main shard),用于存储用户和工作空间信息等非基础范围数据 (non-base-scoped data)。因为历史原因,我们内部也称基础分片为应用程序 live 分片或 live 分片。对我们来说,在生产中测试新的基础分片是相当安全的。我们的内部分片管理系统可以让我们控制如何将新的基础范围数据分配给分片,我们还可以根据负载、硬件问题或其他系统事件在分片之间转移现有的基础范围数据。这意味着我们可以缓慢地提升单个分片的流量,如果出现问题,我们可以安全地将基础范围数据转移到其他地方,并停用有问题的分片。利用这一功能,我们启动了一个运行 MySQL 8 的新基础分片,并转移了几个内部专用基础范围数据。一周后没有出现异常(根据我们的监控和用户报告),我们也开始在 MySQL 8 分区上分配新的基础范围数据。随着 MySQL 8 基础分区上的流量开始增加,我们遇到了两个主要问题,下面将对这两个问题进行描述。问题 1:大型 WHERE IN 子句
我们发现了一个性能问题,在 MySQL 8 中,带有大型 WHERE IN
子句的查询的优化方式不同。我们的查询使用了包含数百甚至数千个值的 WHERE IN
子句。在 MySQL 5.6 中,查询优化器会使用适当的索引进行过滤,但在 MySQL 8 中,优化器会选择执行全表扫描。我们推测,MySQL 8 范围优化器比 5.6 使用更多内存,导致这些查询超出 range_optimizer_max_mem_size
阈值。解决方法很简单:增加 range_optimizer_max_mem_size
,这样之后,我们的查询再次使用了索引。问题 2:插入更新的难题
插入更新(upsert)问题是我们在升级过程中最棘手的。我们注意到 MySQL 8 分区上的死锁率和查询速度明显提高。这种情况每次会不可预测地发生几分钟,在此期间服务器会变得非常慢。大多数查询,即使是看似无害的查询,也需要 30 多秒钟才能完成。性能指标和 InnoDB 状态监控器输出(通过执行 SHOW ENGINE INNODB STATUS
)显示有大量事务被回滚,但不清楚这是根本原因还是潜在问题的症状。我们为 InnoDB 监控输出采样添加了指令,并分析了运行事务在缓慢期间隐含的锁等待图。在调查了多个死胡同后,我们发现了一个关键问题,即大多数受阻查询都是对具有唯一二级索引的表进行 INSERT ... ON DUPLICATE KEY UPDATE
查询,而键冲突发生在唯一二级索引上(而不是主键上)。进一步调查发现了一个MySQL 错误报告(https://bugs.mysql.com/bug.php?id=98324),该报告在 5.7.26 中得到了修复,改变了 INSERT ... ON DUPLICATE KEY UPDATE
查询的锁定行为。错误修复减少了键冲突情况下的锁定范围,其副作用是允许事务更紧密地交错。这意味着像我们这样的工作负载更有可能出现等待和死锁。在我们的案例中,InnoDB 监控输出样本显示问题出在唯一索引的 next key 锁(https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html#innodb-next-key-locks)。当出现某些病态的死锁序列时,一些非常大的事务就会回滚,在回滚的过程中又阻塞了其他事务。Jakub Lopuszanski(https://bugs.mysql.com/bug.php?id=98324#:~:text=%5B4%20Sep%202020%2010%3A11%5D%20Jakub%20Lopuszanski)在 MySQL 错误报告上发表的评论概述了一些可能的解决方法。我们考虑了其中两个方案:调整应用程序工作负载,不使用 AUTO_INCREMENT
主键列;将隔离级别从 REPEATABLE READ
降为 READ COMMITTED
。在 Airtable,我们通常使用应用程序生成的随机 ID 作为主键,但我们仍有使用 AUTO_INCREMENT
主键(以及应用程序生成的 ID 上的唯一索引)的过时的表。我们考虑过完全迁移 AUTO_INCREMENT
主键,以解决间隙锁争用问题,但这需要大量的应用级变更(以及许多 schema 变更),而且仍然无法完全解决具有多个唯一索引的表的问题。因此,我们决定将事务隔离级别(https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html#isolevel_repeatable-read)从 REPEATABLE READ
降为 READ COMMITTED
。在 READ COMMITTED
中,大多数操作根本不使用间隙锁,因此死锁的可能性大大降低。切换的主要缺点是,我们失去了 REPEATABLE READ
提供的快照隔离。幸运的是,我们能够利用基础分片工作负载的一个独特属性。基础分片负责存储基础范围内的数据,每个基础范围数据的操作都通过一个 NodeJS 服务器进程进行序列化。从概念上讲,这意味着我们不应该对基础数据进行多个并发读取或写入操作,因此我们不需要 REPEATABLE READ
更强的快照隔离属性。为了验证我们的理解,我们在数据库访问层中实施了客户端检查,如果我们发出的查询不在单个应用程序的范围内(即缺少 WHERE applicationId = ?
子句),就会产生警告。这种检查捕获了大约 10 个案例,其中没有一个是真正的错误,只需要进行简单修复。修复这些警告后,我们在所有环境中为基础分片启用了 READ COMMITTED
,再也没有出现与 upsert 相关的性能问题。记录/回放
对于主分区,我们必须采取不同的生产测试策略。一般来说,对主分区进行更改风险很大。因为我们只有一个主分区,所以不能像基础分区那样逐步增加和减少流量。这意味着一旦出现问题,整个网站都可能瘫痪。我们决定建立一个记录/回放系统,这样我们就可以根据主分区的快照和实际生产工作负载的记录来执行离线测试。我们还简要考虑了其他方案:记录和回放真实的 SQL 流量是一项艰巨的工作,因为它根本就不牢靠。在记录或回放过程中,轻微的扰动和不精确会导致查询的到达时间或排序不同。这意味着数据库状态会不同,查询到达服务器的确切时间会不同,并发查询获得锁的顺序也会不同。一些在现实生活中有效的插入可能会在回放中失败,随后的更新也会失败。尽管存在这些限制,我们仍希望记录/回放能揭示主要工作负载中的任何重大问题。根据我们在基础分片上的经验,我们认为自己已经意识到了有问题的查询模式(大型 WHERE IN
子句、并发向上插入),并知道要密切关注查询延迟和错误率等指标。在 MySQL 层记录(如通过慢查询记录器 https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)是最准确的,因为它能捕获针对数据库运行的每个查询。但缺点是会给数据库实例增加大量负载。ProxySQL 层的记录也非常准确,但它缺乏有关事务和连接状态的应用级信息,而这些信息对重回保真度非常重要。对于我们来说,ProxySQL 的更改也很难进行增量部署和测试。最后,在应用层进行记录会增加运行查询的额外开销,但它更易于测试和逐步发布。最终,我们选择了在应用层进行记录,因为这便于测试和逐步发布。我们增强了 MySQL 客户端封装程序,以便将查询记录到本地磁盘。然后,我们使用 logrotate 将查询日志发送到 S3。我们应用程序的读取比写入多得多,但大部分读取都是简单的连接和点查询。我们决定记录 100% 的写入,只记录 10% 的读取(以减少开销)。在几周的时间里,我们慢慢增加了记录次数。在发布过程中,我们观察了服务器端和应用程序的性能指标,没有发现任何明显的开销。为了回放流量,我们开发了一个简单的回放客户端,它可以读取记录的查询日志,并针对主数据库的快照回放查询。为了驱动足够的负载,我们使用 Kubernetes 同时运行多个并发回放客户端。整个回放过程从端到端都是自动化的:从快照中创建一个新的 MySQL 实例,将查询日志下载到 EFS,以及在 Kubernetes 上运行和协调回放客户端。我们发现了少量写入错误(不到百万分之一)。经过调试,我们确定这些都是回放过程中引入的人工痕迹(例如,记录了同一条记录的 INSERT
和 UPDATE
,但在重播过程中,INSERT
语句可能需要更长的时间才能完成,导致 UPDATE
语句在 INSERT
语句完成之前就被回放了)。
我们发现并修复了在基础分片上首次遇到的大 WHERE IN
子句问题的更多实例。我们还增加了主分片上 range_optimizer_max_mem_size
的值,并借此机会将其中一些查询分页为更小的批次。
在性能调查过程中,我们还发现了其他一些低效查询,并与应用程序开发人员合作对其进行了优化。这些优化包括强制使用特定索引、减少耗时查询的频率以及重构查询以扫描更少的数据。
在整个过程中,回放工具可以轻松重现上述问题,并验证我们的修复措施。我们还增加了对更快速度回放的支持,并用 3 倍和 5 倍的速度回放对我们的主数据库进行了负载测试。虽然记录/回放有其局限性,但事实证明它仍然非常有用。我们发现并修复了查询计划效率低下的实际问题,并对我们的写入工作负载能否正确执行充满信心。降级过程
没有备份计划,任何基础设施升级计划都是不完整的。即使我们进行了所有的测试和验证工作,我们仍然希望能够在最坏的情况下将主系统安全降级到 MySQL 5.6。我们的计划是建立一个如上图所示的复制体系。我们希望从当前的 MySQL 5.6 主副本 [A],复制到新的 MySQL 8 副本 [B],然后再复制到 MySQL 5.6 后备副本 [C]。然后,我们要运行一致性检查,以确保三台主机上的数据库状态完全相同。升级时,我们将把 MySQL 8 副本 [B] 升为主节点。降级时,我们会将 MySQL 5.6 后备副本 [C] 升级。这听起来很简单,但 MySQL 只支持在连续的主要版本之间复制,而且只支持从较低版本复制到较高版本。我们需要克服这两个限制。为了了解情况,我们首先尝试在 RDS 中配置这种复制拓扑。RDS(正确地)将此配置识别为不支持,但我们还是通过使用 mysql.rds_set_external_master
(https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/mysql-stored-proc-replicating.html) 手动完成了配置。切换到基于行的复制后,从 MySQL 5.6 向 MySQL 8.0 的向上复制似乎有效,但向下复制回 5.6 却无效。我们开始研究,并找到了一篇关于从 MySQL 8.0 复制到 MySQL 5.7 的 Percona 博文(https://www.percona.com/blog/2018/08/07/replicating-mysql-8-0-mysql-5-7/)。MySQL 8.0 使用了 MySQL 5.6 不支持的新默认字符集和校对。我们根据下表更新了 MySQL 8 实例的参数,使其与 MySQL 5.6 的默认设置相匹配:这解决了大部分错误,但仍会间歇性地出现问题。经过进一步调试,我们发现这些问题与一些用于内部记录的 RDS 特定表(mysql.rds_sysinfo、mysql.rds_configuration、mysql.rds_replication_status、mysql.rds_history、mysql.rds_heartbeat2 和 mysql.rds_history)有关。我们找不到重新配置这些 RDS 表的方法,因此编写了一个脚本,使用以下操作序列自动跳过违规的 binlog 条目:这样,我们就可以近乎实时地从 A 数据库顺利复制到 B 数据库,再复制到 C 数据库。在实现了对数据库 C 的可靠复制后,我们的下一个关注点是验证数据是否与上游数据库保持一致。为了回答这个问题,我们编写了另一个脚本:a) 暂停在数据库 A 上的复制;b) 等待数据库 C 跟上;c) 在每个表上运行 MySQL 的CHECKSUM TABLE
,以验证数据已成功从 MySQL 8 复制到 5.6。这让我们确信,如果需要退回到数据库 C,数据不会丢失或损坏。如果需要,可以降级回 MySQL 5.6,这让我们有信心继续升级到 MySQL 8。升级过程
前两个步骤相对安全。它们可以随时执行(甚至回滚),而不会影响生产流量。最后一步,即故障切换到新的主服务器,才是最危险的地方。我们考虑的第一个问题是可怕的「脑裂 」情况(https://www.percona.com/blog/2020/03/26/split-brain-101-what-you-should-know/),即故障转移过程中的错误导致两个 MySQL 实例都认为自己是主实例。当这两个实例接受客户端写入时,它们的状态就会发生变化,从而导致数据不一致。修复「脑裂」是一个代价高昂的手动过程,因此我们希望确保在故障切换过程中发生的错误不会导致「脑裂」。防止「脑裂」的一种方法是「围栏 (fencing)」:防止向旧主站写入数据。典型的围栏方法是使用 MySQL 的 read_only(https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_read_only)参数,在将新主站改为读写模式之前将旧主站改为只读模式。作为额外的保证,我们还使用了一个「隔离」AWS VPC 安全组来阻止来自应用程序的网络流量,并以与 MySQL 的 read_only 参数相同的方式进行协调:禁用到旧主站的流量,启用到新主站的流量,然后执行 DNS 切换。安全组的更改传播速度很快,因此这不会给我们的故障转移程序增加额外的时间。我们考虑的第二个问题是在 failover 过程中出现错误,导致没有可用的主服务器。例如,如果我们无法启用到新主数据库的流量,就会导致无法访问数据库实例,整个站点就会瘫痪。为了解决这个问题,我们开发了一系列脚本,使整个升级过程完全自动化。这样,我们就可以在非生产环境中广泛测试升级,从而增强了我们的信心。我们还将 failover 过程的所有步骤压缩到一个脚本中。这是一项重要的操作简化,因为这意味着无需手动复制粘贴多个命令或在多个终端窗口之间切换。作为最后的检查,我们还问自己:即使我们做了所有的准备和测试,还有什么地方会出错?我们认为,剩下的最大风险就是在升级上线后遇到以前不知道的新性能问题。为了解决这个问题,我们准备了一系列减轻数据库负载的技术:升级当天
所有的准备工作都指向了升级那一天的到来。这无疑是一个关键时刻:MySQL 8 的升级是 Airtable 历史上迄今为止最大规模的一次整体基础设施更换。由于整个过程已经在文档中明确规定,实际上在升级的具体时间 T 之前,我们并没有太多工作要做。我们将升级时间安排在下班后,以尽量减少对客户的潜在影响。作为额外的预防措施,所有参与升级的工程师都在深夜开始工作,我们还非正式地建议他们在下午小睡一会儿。这听起来有点傻,但我们希望整个团队都能休息好,为紧急情况做好准备。T - 2 小时,我们启动了所有必要的连接,准备升级。T - 30 分钟,我们将升级团队集合到 Zoom 上,并验证与生产环境的连接性。T,我们执行规定的升级命令并等待。房间里的紧张气氛溢于言表。最初的迹象是积极的:网站加载了,我们能够加载基础范围数据,而且没有大量错误涌入我们的监控系统。到目前为止,一切顺利。之后,我们开始启动后台服务。这就是我们遇到的第一个障碍:我们的一个计划 cron 作业超时了。我们重新禁用了它,并深入研究。我们意识到,它正在运行一些大型扫描查询,这些查询通常是通过缓存提供的,但我们的新主服务仍处于冷启动状态,正在预热缓存。等了一会儿后,我们重新启用了 cron 作业,让它重新开始处理。T+2 小时,没有再出现任何错误,我们结束了今晚的工作。在接下来的几天里,我们继续积极监控网站的健康状况。两周后,没有再出现任何问题,我们正式宣布升级成功。结论
我们从 MySQL 5.6 升级到 8.0 的旅程堪称一场史诗般的挑战。这一壮举的完成依赖于多个团队工程师的共同付出。我们必须克服许多深层次的技术挑战,并开发出一批与 MySQL 基础架构相关的新测试和运维工具。这次经历给我们带来的一个重要启示是,我们在规划、准备和测试方面花费的时间确实得到了回报。举例来说,拥有安全降级功能(尽管我们并没有使用它)对于做出是否升级的决定是绝对必要的。我们开发的记录/回放系统也已经产生了红利,我们已经用它来测试后续的一系列 MySQL 基础设施变更。此外,我们为升级当天开发的负载分流技术和运行手册在其他网站事故中也证明非常有用。最后,我们感到非常欣慰的是,最终的结果与我们付出的努力相匹配。尽管我们做了充分准备,但在大型升级当天,总有可能出现意外。我们对各种可能的结果都做好了心理准备,我们得到的结果非常接近最佳情况。作为一名老研发,我希望 PostgreSQL 能加上这些功能